fix(metadata): 历史序号 event_seq 不再从一次失败的读里凭空发号 —— 只有「表还没建」可以从 1 开始 (#4825) - #4872
Conversation
`DatabaseLoader.nextEventSeq()` folded every failure of its
`sys_metadata_history` read into one answer:
} catch {
// Table not provisioned yet or driver error — start at 1.
return 1;
}
The comment named BOTH reasons and then answered both the same way. Only
one is benign. With N rows already in the table, a flaky read (connection
drop, timeout, privileges) handed the next history row `event_seq = 1`,
colliding with an existing row — while the insert SUCCEEDED and nothing
was logged. `event_seq` is the ordering key that history listing and
rollback targeting both stand on, so the timeline is silently wrong from
then on, and neither a retry nor a restart repairs it.
This is the #4728 shape one layer down, but the costlier half: not bytes
that never landed, but bytes that landed wrong.
Discriminate by error TYPE, reusing #4728's machinery rather than
starting a second classifier in the same package. `schema-sync-errors.ts`
now holds ONE matcher (code -> errno -> message -> `cause` chain) with
two vocabularies; `isSchemaAlreadyExistsError` keeps its exact signature
and behaviour, and `isMissingTableError` joins it. They are not each
other's negation: both ask "is this the one benign reason?" and default
to not-benign, so an unrecognised error is loud under both.
- benign "table not provisioned" (SQLite `no such table`, PG `42P01` /
`relation "x" does not exist`, MySQL `ER_NO_SUCH_TABLE` / errno 1146)
-> return 1; there are no rows, so nothing can collide.
- everything else -> rethrow. `createHistoryRecord()` reports at
`error` (consequence, why a hole beats a wrong number, the fix) and
SKIPS the history row.
Classification stays narrow on purpose: a bare `does not exist` also
covers role/database/column, all real failures against a table that may
be full of rows, so the message test requires table/relation alongside.
Two boundaries deliberately unchanged: the metadata write itself does not
fail (the record already landed; reporting it failed would be a worse lie
than the bug), and this path's known non-transactional concurrency race
stays a documented limitation — a read failure is a different thing.
Reported once, with an `info` on recovery.
Tests pin the VALUE that lands, not just whether a write happened: with
rows 1 and 2 present, a failed read leaves [1, 2] rather than [1, 2, 1].
Both directions plus a same-call-site/opposite-verdict case, so neither a
`() => true` classifier nor the old `return 1` could pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
复核通过 —— ACCEPT,已标 ready 并送合并队列1. 方向 2 是被证伪的,不是被跳过的我派发时写过:方向 2(确认无生产调用方后直接删除)若成立会更彻底,但前提是真的核实过,而不是看 TSDoc 说它是 legacy 就下结论。dev 去查了,结论是不能删:
并给出了那个词的正确读法:TSDoc 里的 "legacy" 意思是「不是那个 canonical 的事务性生产者」,不是「没有生产调用方」。 一个只读注释就动手删的 PR,会拆掉两条活路由。 2. 复用范式的方式比我要求的更细我要求「能复用就复用,不要另起一套判别逻辑」。实际做法是把 关键设计写在这句里:两者刻意不是彼此的否定,且都默认「非良性」。 并且有一条测试直接钉住它: 若做成互补(不是 A 就是 B),任何无法识别的错误都会被塞进某一边 —— 那正是本单要修的病换个位置复发。 3. 「洞」优于「错的数字」—— 这个取舍是对的真实读失败时:以
记录已经落盘了,这时候让写入失败等于报告一个更大的谎。历史缺一行是可见的缺口;历史多一行撞号的假数据是不可见的污染。 4. 测试断言的是落地的值,不是机制这正是我派发时要求的:危害是「落盘的字节是错的」,测试就得钉住落盘的字节。加上 5. typecheck 两头都量了
核对
范围外发现:#4867 —— 同一个 bug 在 canonical 路径上,而且更糟
一次读失败,恰好恢复了这段代码被写出来就是为了避免的那个行为。 而 dev 还准确指出「在事务里」不构成豁免:事务解决的是并发撞号,不是「把一次读失败折叠成一个编造的值」。 这两件事今天在本仓已经被混淆过一次。 #4867 里同时标出了它的前置决定: Generated by Claude Code |
Fixes #4825
问题:不是「字节没落盘」,是「落盘的字节是错的」
DatabaseLoader.nextEventSeq()把读sys_metadata_history的全部失败折成同一个答案:注释同时点名了两种原因,然后用同一个
return 1对待两者。这是 #4728 刚修掉的同一种形状,但危害是更贵的那一半:
超时、权限)让下一条历史拿到
event_seq = 1,与既有行直接撞号,而 insert 成功、日志一行没有。
event_seq正是历史列表排序与 rollback 定位的依据 —— 撞号之后版本顺序永久不可信,重试不修、重启也不修。
#4632 的机械检查看不到它:
DURABILITY_CRITICAL_CALLEES建在「持久化调用」上,而这一类的危害发生在读上,读的结果决定了写什么(见下「关于词表」)。
为什么走方向 1 而不是方向 2(删掉这条 legacy 路径)
issue 给了两条方向,方向 2(确认无生产调用方后直接删除、收敛到
SysMetadataRepository)更彻底 —— 但核实下来它不成立。TSDoc 里的 "legacy" 指的是「不是 canonical 的事务型
producer」,不是「没有生产调用方」。全仓查证:
MetadataManager.setDatabaseDriver()/setDataEngine()在 platform kernel 上把DatabaseLoader注册为生产 loader;MetadataManager.save()(metadata-manager.ts:1591,以及:408的protocol === 'datasource:' && capabilities.write分发)→DatabaseLoader.save()→createHistoryRecord()→nextEventSeq();MetadataManager.rollback()→DatabaseLoader.registerRollback()→ 同一条路径;rest-route-ledger.ts里的POST /api/v1/meta/:type/:name/rollback与GET /api/v1/meta/:type/:name/history。所以这段代码在跑,而且跑在正是会撞号的那条路上。走方向 1。
改法:按错误类型判别,复用 #4728 的那套机制
packages/metadata/src/utils/schema-sync-errors.ts(#4728 / PR #4823 落地)重构成一个匹配器 + 两个词表:
code→errno→message→ 跟随cause链(上限 4 层),isSchemaAlreadyExistsError()与新增的isMissingTableError()都是它的薄封装。既有predicate 的签名与行为逐字节不变(其原有测试原样通过)。
两个 predicate 不是彼此的取反:各自只回答「这是那唯一一种良性原因吗?」,默认都是
「不良性」,所以两边都不认识的错误在两边都响亮。
no such table: …、Postgres SQLSTATE42P01/relation "…" does not exist、MySQLER_NO_SUCH_TABLE/ errno1146。没有行,就没有可撞的号,
1确实是下一个号 → 静默返回 1。nextEventSeq()原样抛出。调用方createHistoryRecord()以
console.error上报后跳过这条历史记录。判别方向刻意保守。
does not exist本身不够:role "…" does not exist(42704)、database "…" does not exist(3D000)、column "…" does not exist(42703)全是真实失败,而且每一条都对应「表里可能满是行」的场景 —— 判成良性正是撞号的来源。所以消息匹配要求
table/relation 与该短语同现,code 集合只收表级 SQLSTATE。
上报的文案与两条边界
error一行同时给出 AGENTS.md「Degradation log levels」要求的两样东西,外加一个这条特有的:正在悄悄出现空洞,版本时间线与 rollback 目标将不完整;
前者可见、后者无人能发现;
两条边界刻意保持不变:
同一函数里既有的「history 失败不拖垮主操作」策略、以及 [metadata] database-loader 吞掉 sys_metadata 的 DDL 失败后仍置 schemaReady=true —— 第二类降级(#4632 规则),本轮因包冻结未修 #4728 对
ensureHistorySchema()的处理一致。
SysMetadataRepository)。那是被记录过的限制,与「读失败静默重置到 1」是两回事。按「说一次」的纪律:
historySeqFailureReported一次性开关 + 恢复时补一条info。测试
钉住的是落盘的值,不只是「有没有写」——因为危害就在值上:
event_seq落1,console.error/info一行没有;[1, 2]而不是[1, 2, 1](改前就是后者:一条撞号的行成功写入、悄无声息),同时
error响亮上报;() => true的分类器也能过;
save()仍success: true,记录可读回);info,恢复后从2续号而非再从 1;registerRollback()这条 rollback 历史路径同样覆盖;role/database/column does not exist必须判为非良性 +
cause链深度上限。pnpm check:durability-log-level仍绿(8 个 seam,全部响亮或 rethrow),scripts/durability-degradation.baseline.json无需改动(仍为空)。关于词表(
_find是否该进DURABILITY_CRITICAL_CALLEES)—— 只给看法,未动手issue 末尾那个问题是独立决定,本 PR 没有改词表,看法记在这里供维护者判断:
不建议把
_find加进现有词表。 词表里现有的五个条目全是写 / provisioning 调用,失败本身就等于持久化损失,「记 error 或 rethrow」是完整的补救。读不是:绝大多数包着读的
catch合理地停在warn/debug。而且 AST 是按 callee 名字匹配的,加_find(以及必然连坐的
find/findOne/count)会在几百个良性读 seam 上炸开 —— 一个塞满两百条例外的 shrink-only baseline,正是它自己的
$comment警告过的「门禁失去意义」。有害的形状比「catch 里有个读」窄得多,是三部分同时成立:
读失败 →
catch用一个编造的值顶替没读到的数据 → 那个值被写下去 / 记录下来。nextEventSeq三条全中(_find→return 1→event_seq: 1落库);catch { return [] }只让列表渲染成空,前两条中、第三条不中,是完全合法的。所以它需要的是词表之外的第二条规则,形状与 AGENTS.md 里紧跟在降级规则之后的
「Startup registry reads」那条一样(那条也是写侧规则的读侧对应物,靠「三部分同时成立」
而不是靠 callee 词表立住)。问句大致是:
能不能机械化是开放问题,我不会替它打包票。 写侧门禁成立是因为「callee 名在词表里」
是个便宜的 AST 判断;读侧要的是数据流(catch 的返回值最终进了某个被持久化的字段),
通用版本是 taint analysis,而一个 60% 精度的门禁配 shrink-only baseline 比没有更糟。
建议:先把规则写进 AGENTS.md(#4632 的价值本来也主要来自规则本身),机械检查作为独立的、
刻意收窄到「
catch里return一个字面量」的后续单,先量命中率再决定是否上门禁。顺带:#4777(把词表扩到启动期注册表判断)是同一观察的另一侧 —— 两条都说明 #4632 的词表
应当保持原样(只收持久化写调用),新形状各立新规则,而不是往老列表里塞新词。
顺带发现,已单开不在本 PR 修
#4867 ——
packages/metadata-protocol/src/sys-metadata-repository.ts的nextEventSeq()与nextItemVersion()有逐字同形的catch { return 1 }。而那是canonical 路径(本单正文与分诊都把它称作「历史写入应当收敛过去的地方」),并且那里有
两个数字:
event_seq,以及version—— 后者的 TSDoc 明说它刻意从 history 取 MAX「so delete + recreate continues incrementing instead of restarting at 1」,一次读失败正好
把它恢复成它明确要避免的行为,而
rollback(type, name, version)正是按version定位快照。跨包复用本 PR 新增的判别器需要先定它的落点(内部工具 vs 下沉到共同依赖),已在 #4867 里
列出三个选项,留给维护者定。
验证
packages/metadata无typecheckscript(在check-type-check-coverage.mjs的 DEBT 账本里)。手动量了两次
tsc --noEmit:改动前 92,改动后 92 —— 本 PR 新增 0 个类型错误(过程中一版测试写法引入过 5 个
TS2348,已用一个带类型的DriverFind别名 + 单一driverWithBreakableHistoryReads()helper 消掉,顺带去掉了四处重复的 driver 包装)。Generated by Claude Code